El consumo de cerveza en Chile crece año a año. Desde 2007 a la fecha, pasamos de consumir 37 litros a 47, rompiendo récords de importación. El último recuento del año pasado habla de 206 millones de litros, la mayoría desde Estados Unidos, México y Alemania. Pero eso no es todo, ya que la industria nacional también saca réditos de ese auge.
El último encuentro de la Asociación de Productores de Cerveza de Chile (Acechi) hace un mes reveló un dato no menor: la industria de producción nacional es la catorceava en importancia para Chile, representando el 0,1% del PIB chileno.
A su vez, el consumidor, cada vez más informado y exigente, ha desafiado a los productores a desarrollar productos variados, que responden a diversas ocasiones de consumo. Por ello la industria cervecera debe evolucionar en calidad, autenticidad y oferta, adecuándose a los requerimientos de nuevos públicos.
Es por esto que poseer un set de datos con información sobre calificaciones de cervezas realizado por usuarios (personas naturales) podría resultar de gran valor en términos financieros y de mercado, por ejemplo para una eventual empresa ligada a la industria cervecera, pudiendo así definir la fabricación de un producto en función de los gustos explicitados por los evaluadores o también acorde a la valoración que se da a aspectos como el sabor o apariencia.
Los datos recibidos se encuentran en un archivo json con 50.000 entradas, en donde cada entrada corresponde a un review de una cerveza en particular realizado por un usuario. Como pre-procesamiento, a través de un programa en lenguaje python, se toma este archivo json y se convierte a un archivo de texto plano con filas y columnas, el cual puede ser exportado a R studio y a python para su análisis.
Una vez convertido el set de datos a un archivo con filas y columnas, para cada fila que corresponde a un review se pueden identificar 3 grupos de información: Por un lado se tiene información referente a usuarios, los cuales se caracterizan con un nombre de perfil, y opcionalmente pueden proveer su género y fecha de nacimiento. Con respecto a la información de cada cerveza, para estas se indica su nombre, estilo, ABV (The alcoholic content by volume, contenido de alcohol por unidad de volumen), un ID de la cerveza y además un ID de la cervecera. Finalmente se tiene información con respecto al review propiamente tal. El usuario entrega distintas puntuaciones, sobre apariencia, aroma, gusto sabor, y una general, también existe una fecha y hora del review, y además un texto descriptivo.
Se espera poder estimar la percepción que tienen los usuario sobre las cervezas determinado por lo que escriben en el texto descriptivo. En primer lugar, se busca identificar una categoría en función de lo que escribe el usuario para determinar si su percepción del producto es mala, buena, o intermedia. Además, se intentará predecir el valor numérico del puntaje asignado a partir del comentario provisto. Por esto pues, para este análisis en particular, se hará uso de dos columnas del set de datos: El puntaje general otorgado por los usuarios a la cerveza y el texto ingresado. Se procede entonces a hacer una pequeña exploración de las columnas a utilizar.
Se observa en primer lugar el comportamiento de los calificaciones otorgadas por las usuarios.
from IPython.display import Image
Image("punt.png")
Se puede ver que la tendencia de los datos tiene mucha similitud a una distribución normal, y por lo tanto está desbalanceada. Es por esto que se decide crear categorías para las puntuaciones y luego balancear las clases haciendo sub-sampling. Junto a esto, también se realizarán ensayos considerando el set completo de datos, pero usando pesos para las clases. Para el ensayo con sum-sampling, se asume que si un usuario otorga una nota entre 0 y 2.5, su percepción de la cerveza es mala; si la nota está en 2.5 a 3.5 su percepción es intermedia, y superior a 3.5, su percepción de la cerveza es buena.
import pandas as pd
import os
from datetime import *
import numpy as np
import matplotlib.pyplot as plt
data = pd.read_csv('beer2.dat', sep=" ", header=None, names=["ABV","beerId","brewerId","name","style","appearance","aroma","overall","palate","taste","datetime","timeUnix","profileName","ageInSeconds","birthdayRaw","birthdayUnix","gende","text"])
aux_string=[]
for i in range(0,len(data["text"])):
prelim_string ="{0}".format(data["text"][i])
prelim_string=' '.join(prelim_string.split())
aux_string.append(prelim_string)
data["review"]=aux_string
def points_to_class(points):
if points>=0 and points<=2.5:
return 0
elif points>2.5 and points<=3.5:
return 1
else:
return 2
rating = data["overall"].apply(points_to_class)
rating.value_counts()
Se puede ver que, además, se analiza la distribución de datos en las clases asignadas para generar los respectivos pesos.
class_weights = {0: 10,
1: 3,
2: 1}
Por otro lado, se analiza en el texto la presencia de palabras más comunes. Para eso se utiliza el siguiente código
#Grafico de palabras recurrentes. parte de este codigo se tomo de https://towardsdatascience.com/
import seaborn as sns
import matplotlib.pyplot as plt
from nltk.tokenize.treebank import TreebankWordDetokenizer
from nltk.corpus import stopwords
from nltk import word_tokenize
sns.set(style="whitegrid")
stopwords = set(stopwords.words('english'))
detokenizer = TreebankWordDetokenizer()
def clean_description(desc):
desc = word_tokenize(desc.lower())
desc = [token for token in desc if token not in stopwords and token.isalpha()]
return detokenizer.detokenize(desc)
data["cleaned_text"] = data["review"].apply(clean_description)
word_occurrence = data["cleaned_text"].str.split(expand=True).stack().value_counts()
total_words = sum(word_occurrence)
top_words = word_occurrence[:30]/total_words
ax = sns.barplot(x = top_words.values, y = top_words.index)
# Setting title
ax.set_title("% de ocurrencia de palabras más frecuentes")
plt.show()
Además, se analizan por completitud aspectos generales de los datos
data.describe()
data.info()
Como se dijo anteriormente, se desea realizar un ensayo con y sin balanceo de las clases, para lo cual se utiliza el siguiente código de sub-sampling que equipara el número de elementos por clase al de menor cardinalidad:
# Este funcion se tomó de http://www.developintelligence.com/
from collections import Counter
def balance_classes(xs, ys):
freqs = Counter(ys)
max_allowable = freqs.most_common()[-1][1]
num_added = {clss: 0 for clss in freqs.keys()}
new_ys = []
new_xs = []
for i, y in enumerate(ys):
if num_added[y] < max_allowable:
new_ys.append(y)
new_xs.append(xs[i])
num_added[y] += 1
return new_xs, new_ys
aux_string_sub, rating_sub = balance_classes(aux_string, rating)
Puesto que se han definido clases asociadas a los puntajes, el primer predictor a implementar será un clasificador. Para entrenar tanto este clasificador como un regresor se usarán los textos provistos por los usuarios, los cuales por lo tanto deben ser vectorizados de alguna manera.
se utiliza el método TF-IDF "Term Frequency Inverse Document Frequency ", el cual se apoya en la idea de que palabras muy comunes son menos relevantes y así normalizar el vector de palabras utilizando las frecuencias en los textos. Este método está implementado en scikit-learn
from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer(ngram_range=(1,10))
vectors_sub = vectorizer.fit_transform(aux_string_sub)
vectors = vectorizer.fit_transform(aux_string)
Que además permite indicar un número de n-gramas para vectorizar los textos. Esto indica como se agrupan las palabras para indicar la presencia de frases. En este caso se usa el número 10 como límite superior, pues se estima que 10-gramas son capaces de representar las ideas de los usuarios de manera adecuada.
Una vez realizada la vectorización de palabras, como parte del análisis explotario se hace un clustering sobre los vectores para identificar la presencia de caracteristicas similares entre reviews. Para ello se utiliza K-means con distinto número de clusters, y calculando el error, se grafican los resultados con tal de ver si es factible seguir la metodología "del codo".
from sklearn.cluster import KMeans
import matplotlib.pyplot as plt
sse = {}
for k in range(2, 10):
kmeans = KMeans(n_clusters=k, max_iter=1000).fit(vectors)
sse[k] = kmeans.inertia_
plt.figure()
plt.plot(list(sse.keys()), list(sse.values()))
plt.xlabel("Numero de clusters")
plt.ylabel("SSE")
plt.show()
from IPython.display import Image
Image("clustering.png")
Si bien este clustering no aporta mucha información debido al comportamiento de la curva, se debe ver una inflexión considerable en K=3, que se espera pueda tener correlación con la elección de tres clases distintas para categorizar la percepción de los usuarios. Se ve otra inflexión importante en K=8, pero se prescinde de ella debido a que dado que el rango numérico de los datos es entre 0 y 5, definir 8 clases parace un poco excesivo para la naturaleza de los datos.
Como se comentó anteriormente, se realizarán ensayos utilizando el set de datos con y sin sub-sampling. Para el caso sin sub-sampling, se utilizarán pesos definidos para cada clase. A la hora de evaluar el desempeño de los distintos clasificadores, se recurre al método de cross-validation, dividiendo el set de datos en 10 grupos. Así, en cada iteración, se entrena con un 90% de los datos y se prueba con el 10% restante, para luego evaluar los promedios de una serie de indicadores: Precision, recall, F1-score y accuracy.
Por otro lado,se crea un nuevo set de datos de entrenamiento y testing con la misma proporción indicada en el párrafo anterior, con tal de poder visualizar una matriz de confusión sobre los resultados obtenidos:
from sklearn.model_selection import train_test_split
X_train_sub, X_test_sub, y_train_sub, y_test_sub = train_test_split(vectors_sub, rating_sub, test_size=0.1)
X_train, X_test, y_train, y_test = train_test_split(vectors, rating, test_size=0.1)
El primer clasificador a probar corresponde a SVM no lineal. Primero se presentan los resultados obtenidos al usar el set de datos sub-sampleado, y posteriormente el set de datos completo. Para esto se utiliza el siguiente código, que luego muestra su desempeño con distintas métricas, y una matriz de confusión para un ensayo:
#CLASIFICADOR USANDO SVM sumsampled
from sklearn.svm import LinearSVC
from sklearn.svm import NuSVC
from sklearn.model_selection import cross_validate
from sklearn.metrics import accuracy_score
from sklearn.metrics import classification_report
######################################################
######################################################
# SVM NO LINEAL
######################################################
######################################################
classifier1_sub = NuSVC(gamma='auto')
scoring = ['precision_macro', 'recall_macro', 'accuracy', 'f1_macro']
cv_results_sub = cross_validate(classifier1_sub, vectors_sub, rating_sub, cv = 10, n_jobs=10,scoring = scoring, return_train_score= True)
print('Promedio Precision:', np.mean(cv_results_sub['test_precision_macro']))
print('Promedio Recall:', np.mean(cv_results_sub['test_recall_macro']))
print('Promedio F1-score:', np.mean(cv_results_sub['test_f1_macro']))
print('Promedio Accucary:', np.mean(cv_results_sub['test_accuracy']))
classifier1_sub.fit(X_train_sub, y_train_sub)
preds_sub = classifier1_sub.predict(X_test_sub)
from sklearn.metrics import confusion_matrix
cm1_sub = confusion_matrix(y_test_sub, preds_sub)
print(cm1_sub)
El desempeño del clasificador no muestra valores óptimos en las métricas. Su precisión marca aproximadamente un 50% de efectividad, y en la matriz de confusión se puede ver que comete muchos errores a la hora de clasificar, aún cuando los valores en las diagonales son altos.
#CLASIFICADOR USANDO SVM
classifier1 = NuSVC(gamma='auto',class_weight = class_weights,nu=0.1)
cv_results = cross_validate(classifier1, vectors, rating, cv = 10, n_jobs=10,scoring = scoring, return_train_score= True)
print('Promedio Precision:', np.mean(cv_results['test_precision_macro']))
print('Promedio Recall:', np.mean(cv_results['test_recall_macro']))
print('Promedio F1-score:', np.mean(cv_results['test_f1_macro']))
print('Promedio Accucary:', np.mean(cv_results['test_accuracy']))
classifier1.fit(X_train, y_train)
preds = classifier1.predict(X_test)
from sklearn.metrics import confusion_matrix
cm1 = confusion_matrix(y_test, preds)
print(cm1)
Para el set de datos completo, los resultados son bastante similares en término de las métricas. No se observan cambios muy relevantes a la hora de usar este set de datos completo.
El siguiente caso corresponde al uso de un clasificador con árbol de decisión. Análogamente al caso anterior, se estudian métricas para el uso del set de datos diezmado y posteriormente completo.
#CLASIFICADOR USANDO Árbol de Decisión subsampled
from sklearn.tree import DecisionTreeClassifier
classifier2_sub = DecisionTreeClassifier()
cv_results2_sub = cross_validate(classifier2_sub, vectors_sub, rating_sub, cv = 10, n_jobs=10, scoring = scoring, return_train_score= True)
print('Promedio Precision:', np.mean(cv_results2_sub['test_precision_macro']))
print('Promedio Recall:', np.mean(cv_results2_sub['test_recall_macro']))
print('Promedio F1-score:', np.mean(cv_results2_sub['test_f1_macro']))
print('Promedio Accucary:', np.mean(cv_results2_sub['test_accuracy']))
# train the classifier
classifier2_sub.fit(X_train_sub, y_train_sub)
preds2_sub = classifier2_sub.predict(X_test_sub)
from sklearn.metrics import confusion_matrix
cm2_sub = confusion_matrix(y_test_sub, preds2_sub)
print(cm2_sub)
Nuevamente los resultados no son muy prometedores. Las métricas muestran que los resultados son incluso peores que en el caso anterior. Lo mismo ocurre si se clasifica usando el set de datos completo, sin sub-sampling.
#CLASIFICADOR USANDO Árbol de Decisión
from sklearn.tree import DecisionTreeClassifier
classifier2 = DecisionTreeClassifier(class_weight = class_weights)
scoring2 = ['precision_macro', 'recall_macro', 'accuracy', 'f1_macro']
cv_results2 = cross_validate(classifier2, vectors, rating, cv = 10, n_jobs=10, scoring = scoring, return_train_score= True)
print('Promedio Precision:', np.mean(cv_results2['test_precision_macro']))
print('Promedio Recall:', np.mean(cv_results2['test_recall_macro']))
print('Promedio F1-score:', np.mean(cv_results2['test_f1_macro']))
print('Promedio Accucary:', np.mean(cv_results2['test_accuracy']))
# train the classifier
classifier2.fit(X_train, y_train)
preds2 = classifier2.predict(X_test)
from sklearn.metrics import confusion_matrix
cm2 = confusion_matrix(y_test, preds2)
print(cm2)
Finalmente, y con tal de poder comparar, se utiliza un perceptrón multi-capa. Este clasificador, en su implementación de scikit-learn, no permite indicar el peso para las clases, así que solamente se usará el set de datos con sub-sampling.
#CLASIFICADOR USANDO REDES NEURONALES
from sklearn.neural_network import MLPClassifier
# initialise the SVM classifier
classifier3_sub = MLPClassifier(hidden_layer_sizes=(7,7), random_state=1)
cv_results3_sub = cross_validate(classifier3_sub, vectors_sub, rating_sub, cv = 10, n_jobs=10, scoring = scoring, return_train_score= True)
print('Promedio Precision:', np.mean(cv_results3_sub['test_precision_macro']))
print('Promedio Recall:', np.mean(cv_results3_sub['test_recall_macro']))
print('Promedio F1-score:', np.mean(cv_results3_sub['test_f1_macro']))
print('Promedio Accucary:', np.mean(cv_results3_sub['test_accuracy']))
# train the classifier
classifier3_sub.fit(X_train_sub, y_train_sub)
preds3_sub = classifier3_sub.predict(X_test_sub)
from sklearn.metrics import confusion_matrix
cm3_sub = confusion_matrix(y_test_sub, preds3_sub)
print(cm3_sub)
Para este caso, se puede ver que la métrica de precisión mejora hasta un 61% aproximadamente, y en la matriz de confusión se puede ver que los valores mayores están sobre la diagonal, cometiendo errores en un porcentaje importante de los casos por asignar una clase vecina en lugar de la correcta.
Luego del análisis de clasificación, se implementará una serie de regresores para intentar estimar la puntuación asignada por el usuario en función del texto provisto. Para esto, puesto que ya no existen clases, se utilizará siempre el set de datos completo.
En primer lugar se realiza una regresión con SGD. En esto caso se estudian las métricas de explained variance, negative mean absolute error, negative mean squared error, negative mean squared log error, negative median absolute error, y "r2". Al igual que en el caso del clasificador, se realiza cross validación diviendo el set de datos en 10 partes.
from sklearn.linear_model import SGDRegressor
from sklearn.model_selection import cross_validate
regresor1 = SGDRegressor(loss="huber", penalty="elasticnet",max_iter=100, tol=1e-3)
scoring=['explained_variance','max_error','neg_mean_absolute_error','neg_mean_squared_error','neg_mean_squared_log_error','neg_median_absolute_error','r2']
cv_results = cross_validate(regresor1, vectors, data["overall"], cv = 10, n_jobs=10, scoring = scoring, return_train_score= True)
print('explained_variance:', np.mean(cv_results['test_explained_variance']))
print('neg_mean_absolute_error:', np.mean(cv_results['test_neg_mean_absolute_error']))
print('neg_mean_squared_error:', np.mean(cv_results['test_neg_mean_squared_error']))
print('neg_mean_squared_log_error:', np.mean(cv_results['test_neg_mean_squared_log_error']))
print('neg_median_absolute_error:', np.mean(cv_results['test_neg_median_absolute_error']))
print('r2:', np.mean(cv_results['test_r2']))
from sklearn.model_selection import cross_validate
from sklearn.svm import SVR
regresor2 = SVR(gamma='auto')
scoring=['explained_variance','max_error','neg_mean_absolute_error','neg_mean_squared_error','neg_mean_squared_log_error','neg_median_absolute_error','r2']
cv_results2 = cross_validate(regresor2, vectors, data["overall"], cv = 10, n_jobs=10, scoring = scoring, return_train_score= True)
print('explained_variance:', np.mean(cv_results2['test_explained_variance']))
print('neg_mean_absolute_error:', np.mean(cv_results2['test_neg_mean_absolute_error']))
print('neg_mean_squared_error:', np.mean(cv_results2['test_neg_mean_squared_error']))
print('neg_mean_squared_log_error:', np.mean(cv_results2['test_neg_mean_squared_log_error']))
print('neg_median_absolute_error:', np.mean(cv_results2['test_neg_median_absolute_error']))
print('r2:', np.mean(cv_results2['test_r2']))
Puesto que los resultados obtenidos en los dos casos anteriores fueron malos, se busca un enfoque distinto para poder llevar a cabo la regresión. Si bien el regresor puede estar estimando el valor numérico de forma incorrecta, con un cierto intervalo de confianza podría estar más o menos cerca del valor esperado en función de un cierto margen de error. Se define entonces un nuevo regresor, que esta vez tiene asociados intervalos de confianza para cada dato predicho.
Se aprovecha el uso de quantiles en este regresor para poder definir intervalos de confianza. Así, con 3 regresiones distintas, podemos establecer un rango para el valor predicho y así compararlo con lo esperado.
from sklearn.ensemble import GradientBoostingRegressor
X_train_reg, X_test_reg, y_train_reg, y_test_reg = train_test_split(vectors, data["overall"], test_size = 0.1)
low_coef = 0.1
up_coef = 0.9
lower_model = GradientBoostingRegressor(loss="quantile",alpha=low_coef)
mid_model = GradientBoostingRegressor(loss="ls")
upper_model = GradientBoostingRegressor(loss="quantile",alpha=up_coef)
lower_model.fit(X_train_reg,y_train_reg)
mid_model.fit(X_train_reg,y_train_reg)
upper_model.fit(X_train_reg,y_train_reg)
predictions_lower = lower_model.predict(X_test_reg)
predictions_mid = mid_model.predict(X_test_reg)
predictions_upper = upper_model.predict(X_test_reg)
predictions = y_test_reg.tolist()
N_sam = 200
x=np.linspace(1,N_sam,N_sam)
plt.rcParams["figure.figsize"] = (20,10)
plt.plot(x,predictions[0:200],linewidth=5.0)
plt.plot(x,predictions_mid[0:200],linewidth=5.0)
plt.plot(x,predictions_lower[0:200], color='r')
plt.plot(x,predictions_upper[0:200], color='r')
plt.fill_between(x, predictions_lower[0:N_sam],predictions_upper[0:N_sam], color='grey', alpha='0.1')
plt.legend(['Datos', 'predicción','Rango']);
plt.show()
Para este caso particular observadora de 200 muestras, se puede ver que las predicciones en general no aciertan al valor mismo de la calificación. Esto explica para los casos anteriores los valores cercanos a cero de la varianza y score R2 negativos, ya que los modelos pueden ser arbitrariamente malos. Si bien la mayoría de las calificaciones se encuentran dentro del intervalo, aquellas donde la calificación otorgada es mala se escapa del rango. Algo similar ocurre para las calificaciones más altas. Se puede decir entonces que para las predicciones que se escapan de una tendencia media, el modelo no predice bien el valor.
Tanto el proceso de clasificación como el de regresión no tuvieron resultados óptimos, las métricas evidencian precisiones de entre 50% y 60%. Se asocia esta diferencia principalmente a lo relativa que pueda resultar una descripción con respecto a la percepción de la persona: Para ejemplificar, si se utiliza el clasificador para una review de la forma:
"It was excellent, I loved it"
El clasificador sabe sin problemas que la percepción es buena. Pero en cosas más complejas donde un usuario describe por ejemplo un sabor
"...I tasted some chocolate..."
La calificación otorgada es muy variable, pues depende de si a la persona le gusta o no el chocolate; así, se pueden encontrar reviews similares con calificaciones otorgadas muy distintas.
Si bien depende de casos de estudio particulares, se puede concluir que en general, en set de datos con estas características, las métricas obtenidas no sean las óptimas (i.e. precisión por sobre 90%, por ejemplo).